Skip to content

feat(cursor): drive cursor-agent acp as a second app-server dialect#7

Merged
timhanlon merged 1 commit into
mainfrom
feat/cursor-acp-app-server-dialect
Jul 5, 2026
Merged

feat(cursor): drive cursor-agent acp as a second app-server dialect#7
timhanlon merged 1 commit into
mainfrom
feat/cursor-acp-app-server-dialect

Conversation

@timhanlon

Copy link
Copy Markdown
Owner

Summary

Adds the Cursor CLI ACP (Agent Client Protocol, JSON-RPC 2.0 over stdio) dialect alongside codex app-server, sharing the same RPC session path. Both dialects ride one newline-delimited transport but differ in handshake, turn-completion signal, and approval format — each encapsulated in its own driver.

  • app-server-driver.ts — hoists the dialect-neutral contract (AppServerDriver, AppServerDriverError, PendingApproval, TurnResult) out of the codex driver; both dialects implement one interface. Service-layer imports redirected.
  • cursor-acp/ — new ACP driver, normalizer (AcpItem accumulator → ExtractedRows), protocol schemas, and launch fn mirroring codex-appserver/. RpcSessionManager picks the factory by capability.protocol ("codex-app-server" | "acp").
  • transport.tsnotifications/serverRequests are now Queue.Dequeue (enables the ACP driver's Queue.clear bulk-drain), adds respondError, and always emits the "jsonrpc":"2.0" header (codex tolerates it).
  • codex-approval-view.ts — recognizes ACP {optionId, name, kind} options: labels by name, answers with optionId; codex decisions stay opaque passthrough.

ACP specifics the driver handles

  • Turn completion is the session/prompt response ({stopReason}), not a notification.
  • The user turn is never echoed — synthesized from the prompt text.
  • session/load (resume) replays the prior transcript as notifications before its response returns; drained post-handshake so resume accumulates only new turns.
  • The turn folds session/update after the response resolves. The transport routes stdout through one sequential fiber, so every update is queued before the response — making a single Queue.clear race-free.

Deferred (tracked)

  • cursor/ask_question / cursor/create_plan blocking requests rejected with -32601 for now (need their own input surface; tied to plan/ask modes we don't select yet).
  • Mode (agent/plan/ask) + model selection, session/cancel interrupt.
  • Non-execute ACP tools get a generic row; execute/shell is first-class.

Verification

pnpm typecheck clean · pnpm test 531 passed / 2 skipped (live-gated). Scripted-peer tests cover handshake, permissions (incl. numeric id 0), resume/replay-discard, and mid-turn process exit. Live smoke against real cursor-agent acp: echo hello-acp shell call with auto-answered permission produced user msg + tool call + output + assistant reply.

Known follow-up

Greptile review flagged one P2: the -32601 rejection for unsupported server requests uses .pipe(Effect.ignore), which silently swallows a write failure and could leave the agent blocked. Fix is to log-don't-swallow (keeping the loop alive); the same pattern exists in the codex driver's respond(...).pipe(Effect.ignore), so it should be swept across both. Not addressed in this PR.

Add the Cursor CLI ACP (Agent Client Protocol, JSON-RPC 2.0 over stdio)
dialect alongside codex app-server, sharing the RPC session path.

- New src/main/ingest/providers/cursor-acp/ (protocol, driver, normalize,
  launch) mirroring codex-appserver/.
- Hoist the dialect-neutral driver contract to app-server-driver.ts
  (AppServerDriver, AppServerDriverError, PendingApproval, TurnResult); both
  dialects implement one interface.
- AppServerCapability.protocol dialect field ("codex-app-server" | "acp");
  RpcSessionManager dispatches on it. cursor declares launchCmd cursor-agent,
  args ["acp"], protocol "acp".
- Transport always emits the "jsonrpc":"2.0" header (codex tolerates it) and
  gains respondError; notification/server-request channels are Queue.Dequeue.
- codex-approval-view recognizes ACP {optionId, name, kind} options — labels
  by name, answers with optionId; codex decisions stay opaque passthrough.

ACP specifics the driver handles: turn completion is the session/prompt
response ({stopReason}), not a notification; the user turn is never echoed
(synthesized from the prompt text); session/load replays the prior transcript
as notifications before its response (drained post-handshake). The turn folds
session/update after the response resolves — the transport routes stdout
through one sequential fiber, so every update is queued before the response,
making a single Queue.clear race-free.

pnpm typecheck clean; pnpm test 531 passed / 2 skipped (live-gated).
@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown

Greptile Summary

Adds the Cursor ACP (Agent Client Protocol, JSON-RPC 2.0 over stdio) dialect alongside the existing codex app-server driver, sharing the same newline-delimited transport. Both dialects implement the new AppServerDriver interface and are selected by capability.protocol in RpcSessionManager.

  • cursor-acp/driver.ts — new ACP driver with initialize → session/new|load handshake, per-turn Queue.clear drain of buffered session/update notifications (after session/prompt response), and session/request_permissionPendingApproval mapping.
  • transport.tsnotifications/serverRequests promoted to Queue.Dequeue (enabling Queue.clear in the ACP driver); respondError added; \"jsonrpc\":\"2.0\" header always emitted.
  • codex-approval-view.ts — ACP {optionId, name, kind} options detected by optionId presence; payload encodes the bare id so the driver wraps it correctly in { outcome: { outcome: \"selected\", optionId } }.

Confidence Score: 4/5

The core sequential-transport ordering argument (that all session/update notifications land in the queue before the session/prompt response resolves) is sound and verified by the mid-turn-exit test. The two flagged issues are narrow edge cases, not regressions on any normal path.

The items.push before transport.request leaves a logical gap if a turn write-fails while the session stays alive, and the Effect.ignore on respondError silently drops write failures for unsupported requests in both the ACP and codex drivers. Both are narrow and already acknowledged; no normal-path behavior is broken.

src/main/ingest/providers/cursor-acp/driver.ts — the items.push / Effect.ignore patterns discussed above; src/main/ingest/providers/codex-appserver/driver.ts — the same pre-existing Effect.ignore on non-approval respond that this PR carries forward.

Important Files Changed

Filename Overview
src/main/ingest/providers/cursor-acp/driver.ts New ACP driver implementing AppServerDriver over cursor-agent stdio; correctly relies on sequential transport ordering for the post-response Queue.clear drain, but items.push before transport.request creates an orphaned user entry if the prompt write fails without killing the session
src/main/ingest/providers/codex-appserver/transport.ts Notifications and serverRequests promoted from Stream to Queue.Dequeue; respondError added; jsonrpc header always emitted; Queue.shutdown on process exit confirmed correct
src/main/ingest/providers/app-server-driver.ts New dialect-neutral interface (AppServerDriver, PendingApproval, TurnResult, AppServerLaunchParams) extracted cleanly; well-structured abstraction
src/main/ingest/providers/cursor-acp/normalize.ts AcpItem → ExtractedRows normalizer; execute tools map to Shell rows with structured output, others to generic tool rows; mirrors codex-appserver/normalize.ts output shape correctly
src/main/ingest/providers/cursor-acp/protocol.ts Effect Schema definitions for ACP wire shapes; decodeUnknownOption used throughout for safe fallback; unknown variants intentionally absent (decode to None and are skipped)
src/main/services/RpcSessionManager.ts Factory dispatch by req.protocol (acp vs default codex) cleanly inserted; session semaphore serializes turns per session for both dialects
src/main/services/codex-approval-view.ts ACP option shape (optionId/name/kind) detected by optionId presence; payload encoded as JSON.stringify(optionId) so the driver receives the bare id after parse; codex opaque decisions unchanged
tests/cursor-acp-driver.test.ts Scripted-peer tests covering handshake, permission with numeric id 0, resume/replay-discard, and mid-turn process exit; good coverage of the protocol edge cases

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant M as RpcSessionManager
    participant D as CursorAcpDriver
    participant T as AppServerTransport
    participant P as cursor-agent acp

    M->>D: makeCursorAcpDriver(options)
    D->>T: makeAppServerTransport()
    T->>P: spawn process (stdio)
    D->>P: "initialize {protocolVersion:1}"
    P-->>D: "result {agentCapabilities}"
    alt fresh session
        D->>P: "session/new {cwd}"
        P-->>D: "result {sessionId}"
    else resume
        D->>P: "session/load {sessionId, cwd}"
        P-->>D: notifications (replay)
        P-->>D: "result {modes, models}"
        D->>T: Queue.clear(notifications)
    end
    M->>D: runTurn(text)
    D->>D: items.push user message
    D->>P: "session/prompt {sessionId, prompt}"
    loop while prompt running
        P-->>T: "session/update -> notifications queue"
        P-->>T: "session/request_permission -> serverRequests queue"
        T-->>D: serverRequests fiber picks up permission
        D->>P: "respond {outcome:{outcome:selected,optionId}}"
        D->>D: SubscriptionRef.update pendingApprovals
        P-->>T: more session/update notifications
    end
    P-->>D: "session/prompt response {stopReason}"
    D->>T: Queue.clear(notifications)
    D->>D: foldNotification for each update
    D->>D: "flushText() -> assistant message item"
    D->>D: "normalizeAcpSession(items) -> ExtractedRows"
    D-->>M: "TurnResult {status, rows}"
    M->>M: IngestStore.replaceSession(rows)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant M as RpcSessionManager
    participant D as CursorAcpDriver
    participant T as AppServerTransport
    participant P as cursor-agent acp

    M->>D: makeCursorAcpDriver(options)
    D->>T: makeAppServerTransport()
    T->>P: spawn process (stdio)
    D->>P: "initialize {protocolVersion:1}"
    P-->>D: "result {agentCapabilities}"
    alt fresh session
        D->>P: "session/new {cwd}"
        P-->>D: "result {sessionId}"
    else resume
        D->>P: "session/load {sessionId, cwd}"
        P-->>D: notifications (replay)
        P-->>D: "result {modes, models}"
        D->>T: Queue.clear(notifications)
    end
    M->>D: runTurn(text)
    D->>D: items.push user message
    D->>P: "session/prompt {sessionId, prompt}"
    loop while prompt running
        P-->>T: "session/update -> notifications queue"
        P-->>T: "session/request_permission -> serverRequests queue"
        T-->>D: serverRequests fiber picks up permission
        D->>P: "respond {outcome:{outcome:selected,optionId}}"
        D->>D: SubscriptionRef.update pendingApprovals
        P-->>T: more session/update notifications
    end
    P-->>D: "session/prompt response {stopReason}"
    D->>T: Queue.clear(notifications)
    D->>D: foldNotification for each update
    D->>D: "flushText() -> assistant message item"
    D->>D: "normalizeAcpSession(items) -> ExtractedRows"
    D-->>M: "TurnResult {status, rows}"
    M->>M: IngestStore.replaceSession(rows)
Loading

Comments Outside Diff (2)

  1. src/main/ingest/providers/cursor-acp/driver.ts, line 607-625 (link)

    P2 Orphaned user message on prompt-write failure

    items.push({ kind: "message", role: "user", text }) mutates the session-cumulative array before transport.request("session/prompt", ...) is awaited. If that write fails (e.g., a transient stdin.write error that doesn't immediately kill the process), ChatMessageService catches AppServerDriverError and keeps the session alive — so the next runTurn call sees an extra leading user message in items, and normalizeAcpSession will produce a transcript with an orphaned entry.

    In practice the transport typically becomes closed on any write failure (the child dies shortly after), making further turns immediately fail. But the logical gap is real: the push should be inside a rollback-aware structure, or at minimum after the request fires successfully.

  2. src/main/ingest/providers/cursor-acp/driver.ts, line 584-589 (link)

    P2 Silent write failure on unsupported-request rejection

    .pipe(Effect.ignore) swallows any AppServerTransportError from respondError. If the write fails, the agent's cursor/ask_question (or other blocking request) gets no response, leaving it stalled with no observable error in the driver. The PR description already notes this as a known follow-up and points out the same pattern exists on respond(...).pipe(Effect.ignore) in the codex driver (line 163 of codex-appserver/driver.ts). Worth sweeping both sites together when addressed: replacing Effect.ignore with Effect.catchAll(Effect.logWarning(...)) keeps the server-requests loop alive while still surfacing the failure.

Reviews (1): Last reviewed commit: "feat(cursor): drive cursor-agent acp as ..." | Re-trigger Greptile

@timhanlon
timhanlon merged commit 7d0efce into main Jul 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant